Android's declarative UI framework — a complete rethink of how UI is built on Android. No more XML inflation, no more View hierarchies, no more findViewById. Understand the runtime, the composition model, and why this paradigm shift matters at the system level.
Android's original View system was built in 2008, when smartphones had 192MB RAM and single-core CPUs. It was designed around XML layout files inflated into View trees, imperative mutation via setText() and setVisibility(), and a single-threaded main loop that both measured/draws and handles events.
This model accumulated 15+ years of technical debt: double-taxation of layout passes, view state duplication (the View holds state the developer also tracks), no type safety in view binding, and cascading invalidate() chains that redrew far more than necessary. As apps grew more dynamic, developers wrote increasingly fragile synchronization code between data and UI.
XML+View says how to build UI step by step. Compose says what the UI should look like for a given state — the runtime figures out how to get there.
RecyclerView required careful onBindViewHolder resets. In Compose, the composition function is the binding — state is always consistent with what's rendered.
The View hierarchy was a deep inheritance tree (View → TextView → EditText → …). Compose uses function composition — combine small functions, not extend big classes.
No more R.id.button string references and runtime ClassCastExceptions. Parameters are typed Kotlin values. The compiler catches mismatches before the app runs.
React, Flutter, SwiftUI, and Compose all share the same insight: UI is a function of state. Given the same state, the same UI is always produced. The framework's job is to efficiently reconcile the current UI tree with the new one produced by calling your functions — not to let you mutate UI imperatively in ways that can get out of sync with your data model.
The @Composable annotation is more than a marker — it changes the calling convention of the function at the compiler level. The Compose compiler plugin transforms annotated functions to accept an invisible Composer parameter and a bitmask of changed flags. This is what enables the runtime to track what was called, in what order, and skip re-executing unchanged parts.
A @Composable function is not a constructor. It doesn't return a View. Instead, calling it emits nodes into a slot table — a positional, gap-buffered data structure maintained by the Compose runtime in memory. Each call site is identified by its position in the source code (its "key"), not by an ID you assign.
The Compose runtime maintains a slot table (a flat array indexed by "group keys" — integer hashes of call site positions). State values, remembered values, and child groups are stored here. On recomposition, the runtime walks this table and compares old vs new slots — only emitting changes to the actual UI nodes when values differ. This is Compose's equivalent of React's virtual DOM diffing.
// What you write: @Composable fun Greeting(name: String) { Text("Hello, $name") } // What the compiler generates (simplified): fun Greeting( name: String, composer: Composer, // invisible param added changed: Int // bitmask: which params changed ) { // Start a "group" — identifies this call site composer.startRestartGroup(key) val dirty = changed or /* track if name changed */ if (dirty != 0) { // Only re-execute if something changed Text("Hello, $name", composer, ...) } composer.endRestartGroup() ?.updateScope { c, _ -> Greeting(name, c, 1) } } // Key rules for composable functions: // 1. Must be called from another @Composable (or setContent) // 2. Can NOT be called from regular functions // 3. Must NOT have side effects — use Effect APIs instead // 4. Can be called in any order, multiple times (idempotent)
Recomposition is Compose's mechanism for updating the UI when state changes. It's not like React's full component tree re-render — it's surgical, positional, and skippable at the function level.
A composable is skippable if all its parameters are stable. Stable types are those the compiler can prove won't change without notifying Compose: primitives, Strings, @Stable-annotated classes, and @Immutable data classes.
Unstable types — like plain List<T>, arbitrary classes with var properties, or interfaces — force the composable to always recompose even if the values didn't change. This is one of the most common performance pitfalls in Compose.
// ✗ UNSTABLE — List is a plain interface, not @Stable @Composable fun ItemList(items: List<Item>) { ... } // Compose can't skip this — always recomposes // ✓ STABLE — ImmutableList from kotlinx.collections.immutable @Composable fun ItemList(items: ImmutableList<Item>) { ... } // ✓ Or annotate your wrapper class @Immutable data class ItemsState(val items: List<Item>) // ✓ Or use @Stable for classes that notify on change @Stable class MyViewModel : ViewModel() { ... }
Compose identifies composable call sites by their position in the source code. When you call the same composable in a loop, each iteration gets a different key derived from its index. If items are reordered, Compose can get confused — use key(id) { ... } to provide a stable semantic key.
// ✗ Without key: reorder = all items recompose LazyColumn { items(users) { user -> UserRow(user) // keyed by position — fragile } } // ✓ With key: reorder = only moved items recompose LazyColumn { items(users, key = { it.id }) { user -> UserRow(user) // keyed by stable ID } } // ✓ Manual key for conditional composables key(selectedTab) { TabContent(selectedTab) // discards state on tab change }
State in Compose is explicit and observable. When a State<T> object's value changes, every composable that read it during the last composition is scheduled for recomposition. This read-tracking is automatic — the Compose runtime instruments every property read on State objects.
Creates a MutableState<T> backed by a snapshot-aware state object. Any composable reading .value subscribes to changes. Write to .value from any thread — the Compose runtime schedules recomposition on the main thread.
Wraps a calculation whose result is stored in the slot table. The lambda runs once; subsequent recompositions return the stored value. remember(key) { ... } invalidates and re-runs when the key changes.
Like remember but also serializes to the SavedStateHandle (same mechanism as onSaveInstanceState). Works with any Parcelable, Serializable, or types with a custom Saver.
Move state up to the lowest common ancestor that needs it. Make composables stateless by taking state + lambda parameters. This enables testability, reusability, and preview support.
For state that survives rotation or navigation, use ViewModel with StateFlow. Collect in composition via collectAsStateWithLifecycle() — automatically pauses collection when the lifecycle is stopped.
// Local ephemeral state @Composable fun ExpandableCard() { var expanded by remember { mutableStateOf(false) } Card(onClick = { expanded = !expanded }) { AnimatedVisibility(visible = expanded) { FullContent() } } } // State hoisting — stateless composable @Composable fun ExpandableCard( expanded: Boolean, // state in onToggle: () -> Unit // event out ) { ... } // ViewModel screen state @Composable fun HomeScreen(viewModel: HomeViewModel = viewModel()) { val uiState by viewModel.uiState .collectAsStateWithLifecycle() when (val s = uiState) { is Loading -> LoadingSpinner() is Success -> ContentScreen(s.data) is Error -> ErrorScreen(s.message) } } // rememberSaveable with custom Saver val color by rememberSaveable( stateSaver = ColorSaver ) { mutableStateOf(Color.Red) }
// Compose's snapshot system enables safe multi-threaded reads // State reads are tracked per-snapshot (like database MVCC) // Write from background thread safely: withContext(Dispatchers.Default) { // Snapshot.withMutableSnapshot groups writes atomically Snapshot.withMutableSnapshot { state1.value = newVal1 state2.value = newVal2 // observers notified once, not twice } } // derivedStateOf — computed state, only updates when result changes val filteredList by remember { derivedStateOf { items.filter { it.isActive } } } // Recomposes only when the filtered result changes, // NOT every time items list changes (may be same result)
Composable functions must be pure — no side effects in the function body. But real apps need to: launch coroutines, subscribe to flows, register callbacks, and clean up resources. The Effect APIs provide controlled escape hatches.
Launches a coroutine in composition scope. Cancelled and re-launched when key changes. Cancelled when the composable leaves composition. Use for: loading data on entry, animations, one-time events from state changes.
Returns a CoroutineScope bound to the composable's lifetime. Use inside click handlers and callbacks — not for effects that should run on composition. Scope is cancelled when composable leaves.
Runs after every successful recomposition. Use to push Compose state into non-Compose systems (analytics, legacy View references, Firebase). Not cancellable — guaranteed to run.
Like LaunchedEffect but synchronous. Has an onDispose { } block that runs when the key changes or composable leaves. Perfect for: LifecycleObserver, sensors, BroadcastReceiver, view event listeners.
Launches a coroutine that can write to a State value. Use to convert callbacks, Flows, or suspend functions into Compose state. The state's lifetime matches the composable.
Converts Compose state reads into a Flow. Emits a new value whenever the state read inside the block changes. Use to debounce state, combine with other flows, or observe from outside composition.
@Composable fun SearchScreen(query: String) { val results = remember { mutableStateOf<List<Item>>(emptyList()) } // Re-launches when query changes, cancels previous search LaunchedEffect(query) { if (query.isEmpty()) { results.value = emptyList(); return@LaunchedEffect } delay(300) // debounce — cancelled if query changes during delay results.value = repository.search(query) } // Scope for user-triggered actions val scope = rememberCoroutineScope() Button(onClick = { scope.launch { repository.saveSearch(query) } // event handler }) { Text("Save") } } @Composable fun LifecycleAwareScreen(onBackground: () -> Unit) { val lifecycle = LocalLifecycleOwner.current.lifecycle // Register/unregister observer safely DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) onBackground() } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } // always cleaned up } }
Every frame that Compose renders goes through three distinct phases. Understanding them is critical for performance — reading state in the wrong phase causes unnecessary work in other phases.
LayoutNode tree describing what should exist.LayoutNode measures its children and places them. Constraints flow down (parent tells child max size), sizes flow up (child reports its size). Single-pass by design.Canvas. Compose generates a RenderNode display list per composable — hardware-accelerated via the GPU just like Views.graphicsLayer), Composition and Layout are skipped entirely — only the Draw phase runs. Critical for animations.DrawScope or Modifier.drawWithContent lambdas — not at composition time. This keeps animation entirely in the Draw phase, avoiding 60fps recompositions.// ✗ BAD: reads animatable at composition time → recomposition every frame @Composable fun BadAnimation() { val offsetX by animateFloatAsState(100f) Box(Modifier.offset(x = offsetX.dp)) // ← read at composition } // ✓ GOOD: defer read to Layout phase via lambda offset @Composable fun GoodAnimation() { val offsetX by animateFloatAsState(100f) Box(Modifier.offset { IntOffset(offsetX.roundToInt(), 0) }) // Lambda is invoked at Layout phase — no recomposition! } // ✓ BEST for pure visual: defer to Draw phase via graphicsLayer @Composable fun BestAnimation() { val alpha by animateFloatAsState(1f) Box(Modifier.graphicsLayer { this.alpha = alpha }) // graphicsLayer lambda deferred to Draw — skips Composition + Layout }
Moving from XML + View to Compose isn't just a syntax change — it's a paradigm shift. Every imperative View concept has a declarative Compose equivalent, but the mental model is fundamentally different.
Compose and Views can coexist in the same app — you don't have to migrate everything at once.
// Compose INSIDE a View (Fragment/Activity) val composeView = ComposeView(context).apply { setContent { MyComposable() // compose tree starts here } } // A View INSIDE Compose (for legacy widgets) @Composable fun LegacyMapView() { AndroidView( factory = { ctx -> MapView(ctx).apply { ... } }, update = { mapView -> mapView.moveCamera(...) } ) } // Theme bridging — Material3 Compose ↔ AppCompat theme @Composable fun App() { MdcTheme { // MaterialThemeAdapter from accompanist MyScreens() // colors/typography from XML theme } }
| Dimension | XML Views | Compose |
|---|---|---|
| UI definition | XML + Kotlin | Kotlin only |
| Null safety | Runtime NPE risk | Compile-time |
| Previews | Layout Editor | @Preview (faster) |
| Testing | Espresso (slow) | ComposeTestRule |
| Animation | Verbose API | animate*AsState |
| Custom drawing | onDraw override | Canvas DSL |
| Mature ecosystem | 15+ years | Catching up |
| Binary size | Smaller | +~1.5MB runtime |
Compose is built entirely around unidirectional data flow: state flows down through composable parameters, events flow up through lambda callbacks. This wasn't an accident — it's the lesson learned from years of watching developers fight two-way data binding bugs, Adapter notifyDataSetChanged() mysteries, and Fragment state inconsistencies.
Making data flow in one direction means there's always a single source of truth, always a clear path from state change to screen update, and always a clear boundary between "what the UI shows" and "what the user did." This is the same bet that React, Flutter, and SwiftUI all made — and it's now the dominant paradigm for production UI across every platform.
The decision to make composables functions rather than classes was radical. It means: no constructor, no fields, no inheritance. Every piece of UI is a function that takes data in and emits nodes out. This enforces composition-over-inheritance at the language level — you build complex UI by calling functions, not by subclassing ConstraintLayout.
One of Compose's most innovative decisions was to put intelligence in the compiler rather than the runtime. The Compose compiler plugin analyzes your code at build time, inserts stability inference, tracks parameter change bitmasks, and generates restart lambdas. This shifts work that would normally be runtime overhead (like React's reconciler diffing) to build time — resulting in a runtime that's faster and more predictable.
This also means Compose's performance characteristics are visible at the code level: you can see from the types whether a composable will be skippable, whether a lambda will be stable, and whether a state read will cause layout or just drawing — without running the app.
Compose's binary size overhead (~1.5MB), its higher initial compile times, and its learning curve are real costs. The runtime's slot table and snapshot system add memory overhead that a hand-crafted View implementation doesn't have. For very simple screens, raw XML is still faster to write and lighter to ship.
But the trade-off is justified: as UI complexity grows, the imperative View model's costs (bugs, synchronization code, state duplication) grow non-linearly. Compose's costs are roughly fixed — the framework handles the complexity, and your code stays proportional to what the UI actually does.